
繼承上一篇 Never trust user input 的精神,有一種使用者也是操作迅速,彷彿在玩 OSU (如上圖,音樂遊戲),結果不小心按到「刪除」...,然後PM就跑來跟你說客戶覺得產品「很難用」...இдஇ
常見處理方式不外乎就是用 comfirm 來 double check,或是使用 prompt 要求使用者輸入特定文字,例如在 Github 上要刪除專案時,就會要求你輸入特定字串才能進一步刪除。

另一種就是能夠取消的 request,當使用者按下按鈕時,會告知處理中,並附上一顆取消按鈕來讓使用者取消,Google 雲端硬碟上傳時也很常見到。

不論是哪種作法,在設計規劃時,可以依照這個動作的嚴重性來搭配對應的處理方式,本篇要製作的就是最後提及的—可取消功能。
當使用者觸發事件時,會進入 pending 的狀態,並提供可以取消的動作進一步取消剛剛觸發的事件。
理想是:
使用 alert 當作範例,並包裝成如下,現在只要點擊 Button 就會跳出 alert,這是預期之內的動作。
function showAlert(msg) {
  alert("Message:" + msg)
}
function Example() {
  return (
    <Button onClick={() => showAlert("I am alert!")}>
      TRIGGER
    </Button>
  )
}
與前一篇的 useDebounce 雷同,會使用到 setTimeout,但差別在於我們要主動控制而不依賴其他 state 來觸發,因此期望回傳:
function useEventControl(cb, delay) { 
  // 接受一個cb: callback, delay: 延遲時間(ms)
  
  return [startEvent, isPending, cancelEvent]
  //分別是 觸發 / 執行狀態 / 取消 
}
當然也會有 useEffect,使用者會藉由「操控」 isPending 來觸發執行,useEffect 則有 isPending 當作 deps,改變時也會進一步觸發裡面的內容:
const timeoutRef = useRef(null)
const [isPending, setIsPending] = useState(false)
//開始
const startEvent () => {
  if (timeoutRef.current) {
    clearTimeout(timeoutRef.current)
  }
  setIsPending(true)
}
//取消
const cancelEvent = () => {
  if (timeoutRef.current) {
    clearTimeout(timeoutRef.current)
  }
  setIsPending(false)
}
useEffect(() => {
  
  if (isPending) {
    timeoutRef.current = setTimeout(() => {
      cb()
      setIsPending(false)
    }, delay)
  }
  return () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
  }
}, [isPending])
最後調整一下,完整如下:
function useEventControl(cb, delay = 2000) {
  const timeoutRef = useRef(null)
  const [isPending, setIsPending] = useState(false)
  const argsRef = useRef(null)
  const startEvent = useCallback((...args) => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
    argsRef.current = args
    setIsPending(true)
  }, [])
  const cancelEvent = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
    setIsPending(false)
  }, [])
  useEffect(() => {
    if (isPending) {
      timeoutRef.current = setTimeout(() => {
        cb(...argsRef.current)
        setIsPending(false)
      }, delay)
    }
    return () => clearTimeout(timeoutRef.current)
  }, [isPending])
  return [startEvent, isPending, cancelEvent]
}
| Param | Type | Description | 
|---|---|---|
cb | 
function | callback,當 setTimeout 的 delay 時間到時會觸發的動作 | 
delay | 
number | 單位ms,  定義 setTimeout 要 delay 多久,default 為 2000ms | 
這裡預設的delay較久,用意是給使用者時間來反應取消
| Return | Type | Description | 
|---|---|---|
startEvent | 
function | 來觸發event,其中傳入的參數會進一步傳入一開始的cb | 
isPending | 
boolean | 呈現 setTimeout 是否執行的狀態 | 
cancelEvent | 
function | 取消evnet | 
這邊我自己覺得寫起來「比較卡」的地方是要能夠讓 startEvent 接受參數傳入並再丟給 cb 使用,如果有好想法歡迎再跟我分享(鞭策) (๑•́ ₃ •̀๑)
function Example() {
  const [startEvent, isPending, cancelEvent] = useEventControl(showAlert, 2000)
  return (
    <Stack>
      <Button onClick={() => startEvent("YO")} isLoading={isPending}>
        TRIGGER
      </Button>
      <Button onClick={cancelEvent}>CANCEL</Button>
    </Stack>
  )
}
我們把原本的 showAlert 傳入 useEventControl,並設定 2000ms 的 delay,再把 hook 回傳的內容放到各自的位置上。
Chakra-UI 的 <Button />,接受一個 isLoading 來呈現 UI 的狀態,我們也可以把 hook 回傳的 pending 一起傳入。
這樣一來就完成啦!

相比前一篇 useDebounce,本篇實作的對 event 操控性較高,當然應用的場景也有所不同。
功能延伸上,也可以進一步加入 onDone, onCancel 來讓這個 hook 應對更多情境與狀況。